/**
 * Copyright (C) 2010-2014 Leon Blakey <lord.quackstar at gmail.com>
 *
 * This file is part of PircBotX.
 *
 * PircBotX is free software: you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later
 * version.
 *
 * PircBotX is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * PircBotX. If not, see <http://www.gnu.org/licenses/>.
 */
package org.pircbotx;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.primitives.Ints;
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InterruptedIOException;
import java.io.OutputStreamWriter;
import java.lang.ref.WeakReference;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pircbotx.dcc.DccHandler;
import org.pircbotx.exception.IrcException;
import org.pircbotx.hooks.ListenerAdapter;
import org.pircbotx.hooks.events.*;
import org.pircbotx.output.OutputCAP;
import org.pircbotx.output.OutputDCC;
import org.pircbotx.output.OutputIRC;
import org.pircbotx.output.OutputRaw;
import org.pircbotx.snapshot.UserChannelDaoSnapshot;

/**
 * PircBotX is a Java framework for writing IRC bots quickly and easily.
 * <p>
 * It provides an event-driven architecture to handle common IRC events, flood
 * protection, DCC support, ident support, and more. The comprehensive logfile
 * format is suitable for use with pisg to generate channel statistics.
 * <p>
 * Methods of the PircBotX class can be called to send events to the IRC server
 * that it connects to. For example, calling the sendMessage method will send a
 * message to a channel or user on the IRC server. Multiple servers can be
 * supported using multiple instances of PircBotX.
 * <p>
 * To perform an action when the PircBotX receives a normal message from the IRC
 * server, you would listen for the MessageEvent in your listener (see
 * {@link ListenerAdapter}). Many other events are dispatched as well for other
 * incoming lines
 *
 * @author Origionally by:
 * <a href="http://www.jibble.org/">Paul James Mutton</a> for <a
 * href="http://www.jibble.org/pircbot.php">PircBot</a>
 * <p>
 * Forked and Maintained by Leon Blakey in <a
 * href="http://pircbotx.googlecode.com">PircBotX</a>
 */
@Slf4j
@EqualsAndHashCode(of = "botId")
public class PircBotX implements Comparable<PircBotX>, Closeable {
	/**
	 * The definitive version number of this release of PircBotX.
	 */
	//THIS LINE IS AUTOGENERATED, DO NOT EDIT
	public static final String VERSION = "2.1-SNAPSHOT";
	protected static final AtomicInteger BOT_COUNT = new AtomicInteger();
	/**
	 * Unique number for this bot
	 */
	@Getter
	protected final int botId;
	//Utility objects
	/**
	 * Configuration used for this bot
	 */
	@Getter
	protected final Configuration configuration;
	@Getter
	protected final InputParser inputParser;
	/**
	 * User-Channel mapper
	 */
	@Getter
	protected final UserChannelDao<User, Channel> userChannelDao;
	@Getter
	protected final DccHandler dccHandler;
	protected final ServerInfo serverInfo;
	//Connection stuff.
	@Getter(AccessLevel.PROTECTED)
	protected Socket socket;
	protected BufferedReader inputReader;
	protected OutputStreamWriter outputWriter;
	protected final OutputRaw outputRaw;
	protected final OutputIRC outputIRC;
	protected final OutputCAP outputCAP;
	protected final OutputDCC outputDCC;
	/**
	 * Enabled CAP features
	 */
	@Getter
	protected List<String> enabledCapabilities = new ArrayList<String>();
	protected String nick;
	protected boolean loggedIn = false;
	protected Thread shutdownHook;
	protected volatile boolean reconnectStopped = false;
	protected ImmutableMap<String, String> reconnectChannels;
	private State state = State.INIT;
	protected final Object stateLock = new Object();
	protected Exception disconnectException;
	@Getter
	protected String serverHostname;
	@Getter
	protected int serverPort;
	/**
	 *
	 */
	@Getter
	@Setter(AccessLevel.PROTECTED)
	protected boolean nickservIdentified = false;
	private int connectAttempts = 0;
	private int connectAttemptTotal = 0;

	/**
	 * Constructs a PircBotX with the provided configuration.
	 *
	 * @param configuration Fully built Configuration
	 */
	@SuppressWarnings("unchecked")
	public PircBotX(@NonNull Configuration configuration) {
		botId = BOT_COUNT.getAndIncrement();
		this.configuration = configuration;
		this.nick = configuration.getName();

		//Pre-insert an initial User representing the bot itself
		this.userChannelDao = configuration.getBotFactory().createUserChannelDao(this);
		UserHostmask botHostmask = configuration.getBotFactory().createUserHostmask(this, null, configuration.getName(), configuration.getLogin(), null);
		getUserChannelDao().createUser(botHostmask);

		this.serverInfo = configuration.getBotFactory().createServerInfo(this);
		this.outputRaw = configuration.getBotFactory().createOutputRaw(this);
		this.outputIRC = configuration.getBotFactory().createOutputIRC(this);
		this.outputCAP = configuration.getBotFactory().createOutputCAP(this);
		this.outputDCC = configuration.getBotFactory().createOutputDCC(this);
		this.dccHandler = configuration.getBotFactory().createDccHandler(this);
		this.inputParser = configuration.getBotFactory().createInputParser(this);
	}

	/**
	 * Start the bot by connecting to the server. If
	 * {@link Configuration#isAutoReconnect()} is true this will continuously
	 * reconnect to the server until {@link #stopBotReconnect() } is called or
	 * an exception is thrown from connecting
	 *
	 * @throws IOException if it was not possible to connect to the server.
	 * @throws IrcException
	 */
	public void startBot() throws IOException, IrcException {
		//Begin magic
		reconnectStopped = false;
		do {
			//Try to connect to the server, grabbing any exceptions
			LinkedHashMap<InetSocketAddress, Exception> connectExceptions = Maps.newLinkedHashMap();
			try {
				connectAttemptTotal++;
				connectAttempts++;
				connectExceptions.putAll(connect());
			} catch (Exception e) {
				//Initial connect exceptions are returned in the map, this is a more serious error
				log.error("Exception encountered during connect", e);
				connectExceptions.put(new InetSocketAddress(serverHostname, serverPort), e);

				if (!configuration.isAutoReconnect())
					throw new RuntimeException("Exception encountered during connect", e);
			} finally {
				if (!connectExceptions.isEmpty())
					Utils.dispatchEvent(this, new ConnectAttemptFailedEvent(this,
							configuration.getAutoReconnectAttempts() - connectAttempts,
							ImmutableMap.copyOf(connectExceptions)));

				//Cleanup if not already called
				synchronized (stateLock) {
					if (state != State.DISCONNECTED)
						shutdown();
				}
			}

			//No longer connected to the server
			if (!configuration.isAutoReconnect())
				return;
			if (reconnectStopped) {
				log.debug("stopBotReconnect() called, exiting reconnect loop");
				return;
			}
			if (connectAttempts == configuration.getAutoReconnectAttempts()) {
				throw new IOException("Failed to connect to IRC server(s) after " + connectAttempts + " attempts");
			}

			//Optionally pause between attempts, useful if network is temporarily down
			if (configuration.getAutoReconnectDelay() > 0)
				try {
					log.debug("Pausing for {} milliseconds before connecting again", configuration.getAutoReconnectDelay());
					Thread.sleep(configuration.getAutoReconnectDelay());
				} catch (InterruptedException e) {
					throw new RuntimeException("Interrupted while pausing before the next connect attempt", e);
				}
		} while (connectAttempts < configuration.getAutoReconnectAttempts());
	}

	/**
	 * Do not try connecting again in the future.
	 */
	public void stopBotReconnect() {
		reconnectStopped = true;
	}

	/**
	 * Attempt to connect to the specified IRC server using the supplied port
	 * number, password, and socketFactory. On success a {@link ConnectEvent}
	 * will be dispatched
	 *
	 * @throws IOException if it was not possible to connect to the server.
	 * @throws IrcException if the server would not let us join it.
	 */
	protected ImmutableMap<InetSocketAddress, Exception> connect() throws IOException, IrcException {
		synchronized (stateLock) {
			//Server id
			Utils.addBotToMDC(this);
			if (isConnected())
				throw new IrcException(IrcException.Reason.AlreadyConnected, "Must disconnect from server before connecting again");
			if (getState() == State.CONNECTED)
				throw new RuntimeException("Bot is not connected but state is State.CONNECTED. This shouldn't happen");
			if (configuration.isIdentServerEnabled() && IdentServer.getServer() == null)
				throw new RuntimeException("UseIdentServer is enabled but no IdentServer has been started");

			//Reset capabilities
			enabledCapabilities = new ArrayList<String>();

			//Pre-insert an initial User representing the bot itself
			getUserChannelDao().close();
			UserHostmask botHostmask = configuration.getBotFactory().createUserHostmask(this, null, configuration.getName(), configuration.getLogin(), null);
			getUserChannelDao().createUser(botHostmask);

			//On each server the user gives us, try to connect to all the IP addresses
			ImmutableMap.Builder<InetSocketAddress, Exception> connectExceptions = ImmutableMap.builder();
			int serverEntryCounter = 0;
			ServerEntryLoop:
			for (Configuration.ServerEntry curServerEntry : configuration.getServers()) {
				serverEntryCounter++;
				serverHostname = curServerEntry.getHostname();
				//Hostname and port
				Utils.addBotToMDC(this);
				log.info("---Starting Connect attempt {}/{}", connectAttempts, configuration.getAutoReconnectAttempts() + "---");

				int serverAddressCounter = 0;
				InetAddress[] serverAddresses = InetAddress.getAllByName(serverHostname);
				for (InetAddress curAddress : serverAddresses) {
					serverAddressCounter++;
					String debug = Utils.format("[{}/{} address left from {}, {}/{} hostnames left] ",
							String.valueOf(serverAddresses.length - serverAddressCounter),
							String.valueOf(serverAddresses.length),
							serverHostname,
							String.valueOf(configuration.getServers().size() - serverEntryCounter),
							String.valueOf(configuration.getServers().size())
					);
					log.debug("{}Atempting to connect to {} on port {}", debug, curAddress, curServerEntry.getPort());
					try {
						socket = configuration.getSocketFactory().createSocket(curAddress, curServerEntry.getPort(), configuration.getLocalAddress(), 0);

						//No exception, assume successful
						serverPort = curServerEntry.getPort();
						break ServerEntryLoop;
					} catch (Exception e) {
						connectExceptions.put(new InetSocketAddress(curAddress, curServerEntry.getPort()), e);
						log.warn("{}Failed to connect to {} on port {}",
								debug,
								curAddress,
								curServerEntry.getPort(),
								e);
					}
				}
			}

			//Make sure were connected
			if (socket == null || (socket != null && !socket.isConnected())) {
				return connectExceptions.build();
			}
			state = State.CONNECTED;
			socket.setSoTimeout(configuration.getSocketTimeout());
			log.info("Connected to server.");

			changeSocket(socket);
		}

		configuration.getListenerManager().dispatchEvent(new SocketConnectEvent(this));

		if (configuration.isIdentServerEnabled())
			IdentServer.getServer().addIdentEntry(socket.getInetAddress(), socket.getPort(), socket.getLocalPort(), configuration.getLogin());

		if (configuration.isCapEnabled())
			// Attempt to initiate a CAP transaction.
			sendCAP().getSupported();

		// Attempt to join the server.
		if (configuration.isWebIrcEnabled())
			sendRaw().rawLineNow("WEBIRC " + configuration.getWebIrcPassword()
					+ " " + configuration.getWebIrcUsername()
					+ " " + configuration.getWebIrcHostname()
					+ " " + configuration.getWebIrcAddress().getHostAddress());
		if (StringUtils.isNotBlank(configuration.getServerPassword()))
			sendRaw().rawLineNow("PASS " + configuration.getServerPassword());

		sendRaw().rawLineNow("NICK " + configuration.getName());
		sendRaw().rawLineNow("USER " + configuration.getLogin() + " 8 * :" + configuration.getRealName());

		//Start input to start accepting lines
		startLineProcessing();

		return ImmutableMap.of();
	}

	protected void changeSocket(Socket socket) throws IOException {
		this.socket = socket;
		this.inputReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), configuration.getEncoding()));
		this.outputWriter = new OutputStreamWriter(socket.getOutputStream(), configuration.getEncoding());
	}

	protected void startLineProcessing() {
		while (true) {
			//Get line from the server
			String line;
			try {
				line = inputReader.readLine();
			} catch (InterruptedIOException iioe) {
				// This will happen if we haven't received anything from the server for a while.
				// So we shall send it a ping to check that we are still connected.
				sendRaw().rawLine("PING " + (System.currentTimeMillis() / 1000));
				// Now we go back to listening for stuff from the server...
				continue;
			} catch (Exception e) {
				if (Thread.interrupted()) {
					System.out.println("--- PircBotX interrupted during read, aborting reconnect loop and shutting down ---");
					stopBotReconnect();
					break;
				} else if (socket.isClosed()) {
					log.info("Socket is closed, stopping read loop and shutting down");
					break;
				} else {
					disconnectException = e;
					//Something is wrong. Assume its bad and begin disconnect
					log.error("Exception encountered when reading next line from server", e);
					line = null;
				}
			}

			if (Thread.interrupted()) {
				System.out.println("--- PircBotX interrupted during read, aborting reconnect loop and shutting down ---");
				stopBotReconnect();
				break;
			}

			//End the loop if the line is null
			if (line == null)
				break;

			//Start acting the line
			try {
				inputParser.handleLine(line);
			} catch (Exception e) {
				//Exception in client code. Just log and continue
				log.error("Exception encountered when parsing line " + line, e);
			}

			if (Thread.interrupted()) {
				System.out.println("--- PircBotX interrupted during parsing, aborting reconnect loop and shutting down ---");
				stopBotReconnect();
				break;
			}
		}

		//Now that the socket is definitely closed call event, log, and kill the OutputThread
		shutdown();
	}

	/**
	 * Actually sends the raw line to the server. This method is NOT
	 * SYNCHRONIZED since it's only called from methods that handle locking
	 *
	 * @param line
	 * @throws java.io.IOException
	 */
	protected void sendRawLineToServer(String line) throws IOException {
		if (line.length() > configuration.getMaxLineLength() - 2)
			line = line.substring(0, configuration.getMaxLineLength() - 2);
		outputWriter.write(line + "\r\n");
		outputWriter.flush();

		List<String> lineParts = Utils.tokenizeLine(line);
		getConfiguration().getListenerManager().dispatchEvent(new OutputEvent(this, line, lineParts));
	}

	protected void onLoggedIn(String nick) {
		this.loggedIn = true;
		setNick(nick);

		//Were probably connected to the server at this point
		this.connectAttempts = 0;

		if (configuration.isShutdownHookEnabled())
			Runtime.getRuntime().addShutdownHook(shutdownHook = new PircBotX.BotShutdownHook(this));
	}

	public OutputRaw sendRaw() {
		return outputRaw;
	}

	public OutputIRC sendIRC() {
		return outputIRC;
	}

	public OutputIRC send() {
		return outputIRC;
	}

	public OutputCAP sendCAP() {
		return outputCAP;
	}

	public OutputDCC sendDCC() {
		return outputDCC;
	}

	/**
	 * Sets the internal nick of the bot. This is only to be called by the
	 * PircBotX class in response to notification of nick changes that apply to
	 * us.
	 *
	 * @param nick The new nick.
	 */
	protected void setNick(String nick) {
		this.nick = nick;
	}

	/**
	 * Returns the current nick of the bot. Note that if you have just changed
	 * your nick, this method will still return the old nick until confirmation
	 * of the nick change is received from the server.
	 *
	 * @since PircBot 1.0.0
	 *
	 * @return The current nick of the bot.
	 */
	public String getNick() {
		return nick;
	}

	/**
	 * Returns whether or not the PircBotX is currently connected to a server.
	 * The result of this method should only act as a rough guide, as the result
	 * may not be valid by the time you act upon it.
	 *
	 * @return True if and only if the PircBotX is currently connected to a
	 * server.
	 */
	@Synchronized("stateLock")
	public boolean isConnected() {
		return socket != null && !socket.isClosed();
	}

	/**
	 * Returns a String representation of this object. You may find this useful
	 * for debugging purposes, particularly if you are using more than one
	 * PircBotX instance to achieve multiple server connectivity. The format of
	 * this String may change between different versions of PircBotX but is
	 * currently something of the form 	 <code>
	 *   Version{PircBotX x.y.z Java IRC Bot - www.jibble.org}
	 *   Connected{true}
	 *   Server{irc.dal.net}
	 *   Port{6667}
	 *   Password{}
	 * </code>
	 *
	 * @since PircBot 0.9.10
	 *
	 * @return a String representation of this object.
	 */
	@Override
	public String toString() {
		return "Version{" + configuration.getVersion() + "}"
				+ " Connected{" + isConnected() + "}"
				+ " Server{" + getServerHostname() + "}"
				+ " Port{" + getServerPort() + "}"
				+ " Password{" + configuration.getServerPassword() + "}";
	}

	/**
	 * Gets the bots own user object.
	 *
	 * @return The user object representing this bot
	 * @see UserChannelDao#getUserBot()
	 */
	public User getUserBot() {
		return userChannelDao.getUser(getNick());
	}

	/**
	 * @return the serverInfo
	 */
	public ServerInfo getServerInfo() {
		return serverInfo;
	}

	public InetAddress getLocalAddress() {
		return socket.getLocalAddress();
	}

	public int getConnectionId() {
		return connectAttemptTotal;
	}

	/**
	 * Get the auto reconnect channels and clear local copy
	 *
	 * @return
	 */
	protected ImmutableMap<String, String> reconnectChannels() {
		ImmutableMap<String, String> reconnectChannelsLocal = reconnectChannels;
		reconnectChannels = null;
		return reconnectChannelsLocal;
	}

	/**
	 * If for some reason you absolutely need to stop PircBotX now instead of
	 * gracefully closing with {@link OutputIRC#quitServer() }, this will close
	 * the socket which causes read loop to terminate which will shutdown
	 * PircBotX shortly.
	 *
	 * @see OutputIRC#quitServer()
	 */
	public void close() {
		try {
			socket.close();
		} catch (Exception e) {
			log.error("Can't close socket", e);
		}
	}

	/**
	 * Fully shutdown the bot and all internal resources. This will close the
	 * connections to the server, kill background threads, clear server specific
	 * state, and dispatch a DisconnectedEvent
	 */
	private void shutdown() {
		UserChannelDaoSnapshot daoSnapshot;
		synchronized (stateLock) {
			log.debug("---PircBotX shutdown started---");
			if (state == State.DISCONNECTED)
				throw new RuntimeException("Cannot call shutdown twice");
			state = State.DISCONNECTED;

			if (configuration.isIdentServerEnabled())
				IdentServer.getServer().removeIdentEntry(socket.getInetAddress(), socket.getPort(), socket.getLocalPort(), configuration.getLogin());

			//Close the socket from here and let the threads die
			if (socket != null && !socket.isClosed())
				try {
					socket.close();
				} catch (Exception e) {
					log.error("Cannot close socket", e);
				}

			//Cache channels for possible next reconnect
			ImmutableMap.Builder<String, String> reconnectChannelsBuilder = ImmutableMap.builder();
			for (Channel curChannel : userChannelDao.getAllChannels()) {
				String key = (curChannel.getChannelKey() == null) ? "" : curChannel.getChannelKey();
				reconnectChannelsBuilder.put(curChannel.getName(), key);
			}
			reconnectChannels = reconnectChannelsBuilder.build();

			//Clear relevant variables of information
			loggedIn = false;
			daoSnapshot = (configuration.isSnapshotsEnabled()) ? userChannelDao.createSnapshot() : null;
			userChannelDao.close();
			inputParser.close();
			dccHandler.close();
		}

		//Dispatch event
		configuration.getListenerManager().dispatchEvent(new DisconnectEvent(this, daoSnapshot, disconnectException));
		disconnectException = null;
		log.debug("Disconnected.");

		//Shutdown listener manager
		configuration.getListenerManager().shutdown(this);
	}

	/**
	 * Compare {@link #getBotId() bot id's}. This is useful for sorting lists of
	 * Channel objects.
	 *
	 * @param other Other channel to compare to
	 * @return the result of calling compareToIgnoreCase on channel names.
	 */
	public int compareTo(PircBotX other) {
		return Ints.compare(getBotId(), other.getBotId());
	}

	/**
	 * @return the state
	 */
	@Synchronized("stateLock")
	public State getState() {
		return state;
	}

	protected static class BotShutdownHook extends Thread {
		protected final WeakReference<PircBotX> thisBotRef;

		public BotShutdownHook(PircBotX bot) {
			this.thisBotRef = new WeakReference<PircBotX>(bot);
			setName("bot" + BOT_COUNT + "-shutdownhook");
		}

		@Override
		public void run() {
			PircBotX thisBot = thisBotRef.get();
			if (thisBot != null && thisBot.getState() != PircBotX.State.DISCONNECTED) {
				thisBot.stopBotReconnect();
				thisBot.sendIRC().quitServer();
				try {
					if (thisBot.isConnected())
						thisBot.socket.close();
				} catch (IOException ex) {
					log.debug("Unabloe to forcibly close socket", ex);
				}
			}
		}
	}

	public static enum State {
		INIT,
		CONNECTED,
		DISCONNECTED
	}
}
